백업이 아니라 복구를 테스트해야 하는 이유

백업이 아니라 복구를 테스트해야 하는 이유

한눈에 보기

백업 작업의 성공은 파일이 생성됐다는 사실만 증명한다. 실제 복구에는 올바른 복구 지점 선택, 암호화 키와 권한, base backup과 변경 로그의 연속성, 애플리케이션 호환성, 데이터 정합성, DNS와 secret까지 필요하다. 그래서 백업률보다 정기적인 restore drill의 성공률과 실제 소요 시간을 관리해야 한다.

목차

백업 성공과 복구 성공은 다른 사건이다

백업 대시보드에 다음처럼 표시되면 안심하기 쉽다.

Last backup: 2026-04-10 02:00
Status: SUCCESS
Size: 184 GB
Checksum: OK
Retention: 35 days

하지만 이 정보만으로는 다음 질문에 답할 수 없다.

백업 job의 관점에서 성공은 대개 “업로드 API가 성공했고 예상 크기의 객체가 생겼다”에 가깝다. 복구의 관점에서는 “선택한 시점의 올바른 데이터를 사용 가능한 서비스로 되돌렸다”가 성공이다.

flowchart LR
    A[Backup job 성공] --> B[객체 존재]
    B --> C[복호화 가능]
    C --> D[Restore 도구가 읽음]
    D --> E[DB 기동]
    E --> F[정합성 검증]
    F --> G[앱 호환]
    G --> H[사용자 기능 복구]

앞 단계가 성공해도 뒤 단계는 실패할 수 있다. 그래서 최종 단계에서 거꾸로 검증해야 한다.

운영해야 할 지표

“지난 30일 backup job 성공률”과 함께 “최근 restore drill 성공 시각, 복구된 지점, 실제 RTO, 검증한 데이터 범위”를 관리한다.

복구를 막는 실패 지점들

실제 복구가 실패하는 원인은 백업 파일 손상만이 아니다.

구간 실패 예시
생성 일부 table 제외, 중간 실패를 성공 처리
업로드 마지막 multipart 누락, 잘못된 bucket
보존 lifecycle policy가 base backup을 먼저 삭제
암호화 KMS key 삭제, 복호화 권한 없음
선택 timezone 혼동으로 사고 이후 시점 선택
복원 database engine·extension version 불일치
재생 WAL/binlog 구간 누락
구성 DB config, users, grants, network policy 누락
애플리케이션 복구 schema와 실행 version 비호환
검증 DB는 켜졌지만 핵심 row가 없음
전환 DNS, connection string, secret 갱신 실패

다음과 같은 사고를 생각해 보자.

02:00 full backup 완료
09:00 application deploy
14:20 잘못된 DELETE 실행
14:35 사고 발견

14:19까지 point-in-time recovery가 가능하다고 생각했지만 11:40~12:05의 변경 로그가 누락됐다면 02:00 이후 전체 체인을 재생할 수 없다. 최신 backup 객체가 존재한다는 사실과 원하는 RPO를 달성할 수 있다는 사실은 다르다.

또 다른 흔한 문제는 권한이다.

backup writer role
  └─ object:Put 허용

restore operator role
  └─ object:Get 없음

백업 자동화는 매일 성공했지만 재해 복구 계정에서는 파일을 읽을 수 없다. 평상시 권한이 엄격한 것은 좋지만 복구 역할과 break-glass 절차를 실제로 테스트해야 한다.

Replication과 Snapshot은 Backup을 대신하지 못한다

Replica가 있으면 주 서버 장애에서 빠르게 전환할 수 있다. 그러나 잘못된 DELETE, 손상된 데이터, ransomware, 애플리케이션 버그도 replica로 그대로 복제될 수 있다.

sequenceDiagram
    participant P as Primary
    participant R as Replica
    participant B as Immutable Backup

    P->>R: 정상 변경 복제
    P->>B: 시점별 백업
    P->>P: 실수로 데이터 삭제
    P->>R: 삭제도 복제
    Note over B: 과거 복구 지점은 보존
수단 잘 해결하는 문제 해결하지 못할 수 있는 문제
동기 replica 노드·디스크 장애, 빠른 failover 논리 삭제, 계정 침해
비동기 replica 지역 장애, 읽기 확장 lag만큼 데이터 손실, 논리 오류
storage snapshot 빠른 volume 복원 애플리케이션 일관성, 장기 보존
logical dump 일부 객체 확인·이식 대용량에서 느린 복구
base backup + log archive point-in-time recovery 로그 누락, 운영 복잡성
immutable/offline copy 계정 침해와 삭제 방어 최신 RPO를 단독으로 보장하지 않음

Snapshot도 유용한 backup 방식이지만 snapshot이 있다는 사실만으로 충분하지 않다.

“snapshot이 있으니 복구된다”가 아니라 “이 snapshot으로 격리 환경에서 database를 기동하고 정합성을 검증했다”가 증거다.

무엇을 복구해야 서비스가 돌아오는가

데이터베이스만 복구해도 서비스가 동작하지 않을 수 있다. 복구 대상 인벤토리를 만든다.

Application service
├── relational database
├── object storage
├── search index
├── queue and dead-letter queue
├── secrets and encryption keys
├── runtime configuration
├── infrastructure definitions
├── DNS and certificates
└── external provider configuration

모든 것을 같은 방식으로 백업할 필요는 없다.

데이터 복구 전략 예시
원본 게시글 DB PITR 가능한 backup
업로드 이미지 versioning + cross-account copy
검색 index 원본 DB에서 재생성
cache 백업하지 않고 warm-up
queue broker 보존과 idempotent replay
secret 별도 보호된 recovery 절차
IaC version control과 artifact 보존
배포 이미지 immutable registry와 digest

재생성 가능한 데이터도 복구 시간에 영향을 준다. 검색 index를 다시 만드는 데 18시간이 걸리는데 RTO가 4시간이면 “재생성 가능”만으로 충분하지 않다. 축소된 기능으로 먼저 서비스할지, index snapshot을 별도 보존할지 결정해야 한다.

복구 우선순위

Tier 0: 인증, 네트워크, key와 secret
Tier 1: 원본 DB와 핵심 API
Tier 2: object storage와 사용자 파일
Tier 3: search, recommendation, analytics

의존 관계와 병렬화 가능한 단계를 diagram으로 기록한다.

flowchart TD
    I[Infrastructure 준비] --> K[Key·Secret 복구]
    I --> N[Network·DNS 준비]
    K --> D[Database 복구]
    K --> O[Object storage 연결]
    D --> A[핵심 API 기동]
    O --> A
    A --> S[Smoke test]
    D --> X[Search index 재생성]
    S --> T[제한된 트래픽 전환]
    X --> F[전체 기능 복구]

복구 목표와 시나리오를 먼저 정한다

복구 테스트에는 구체적인 사고 시나리오가 필요하다.

시나리오 A: 최신 database instance가 손실됨
시나리오 B: 30분 전 잘못된 데이터 삭제
시나리오 C: primary cloud account 접근 불가
시나리오 D: 특정 region 전체 장애
시나리오 E: backup 계정 credential 침해
시나리오 F: object storage와 DB 시점 불일치

각 시나리오에 복구 목표를 붙인다.

RPO: 허용 가능한 데이터 손실 범위
RTO: 서비스 복구까지 허용되는 시간

예를 들어 RPO 15분, RTO 2시간이라면 테스트는 “어제 backup으로 복구 성공”에서 끝나면 안 된다.

RPO와 RTO를 계산하고 비용과 연결하는 방법은 RPO와 RTO로 복구 목표 정하기에서 더 자세히 다룬다.

가장 쉬운 복구 지점만 선택하지 않는다

매번 최신 full backup만 복구하면 로그 중간 누락, 오래된 retention 계층, 특정 날짜의 key rotation 문제를 찾지 못한다. 정책 범위 안에서 임의 시점과 오래된 복구 지점도 표본으로 고른다.

격리된 환경에서 복구한다

운영 database 위에 restore test를 실행해서는 안 된다. 별도 account, project, network, database instance를 사용한다.

flowchart LR
    P[Production] -->|backup copy| V[Recovery Vault]
    V -->|restore drill| I[Isolated Recovery Account]
    I --> Q[Quarantine Network]
    Q --> T[Test Database]
    T --> A[Test Application]

격리는 단순히 다른 database 이름을 쓰는 수준보다 강해야 한다.

복구한 애플리케이션은 외부 side effect를 stub이나 sink로 보낸다.

environment: restore-drill

outbound:
  email:
    mode: sink
  sms:
    mode: disabled
  webhook:
    mode: capture-only

queues:
  namespace: restore-drill-example

authentication:
  external_login: disabled

민감한 production data를 복구하므로 staging보다 더 엄격한 접근 통제가 필요할 수도 있다. 마스킹을 적용하면 실제 복구 절차와 시간이 달라질 수 있으므로 “원본 복구 검증”과 “개발용 익명화 복제”를 구분한다.

파일 무결성과 데이터 정합성을 나눠 검증한다

Checksum이 맞으면 전송 중 파일이 변하지 않았다는 사실은 확인할 수 있다. 그러나 내용이 올바른 backup인지는 알 수 없다.

checksum verified
  ≠ archive is complete
  ≠ database can start
  ≠ application data is correct

검증 단계를 여러 층으로 나눈다.

1. Backup artifact 검증

- object 존재와 크기
- checksum 또는 signature
- manifest와 segment 개수
- 암호화 metadata와 key ID
- retention과 immutability 상태

2. Database engine 검증

- restore command 성공
- engine이 정상 기동
- recovery가 의도한 시점에서 종료
- extension과 collation 호환
- system catalog 검사

3. Schema 검증

SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;

필수 table, index, constraint, trigger, migration version을 확인한다.

SELECT version, applied_at
FROM schema_migrations
ORDER BY applied_at DESC
LIMIT 10;

4. 도메인 정합성 검증

단순 row count보다 서비스 규칙을 확인한다.

-- 게시글이 참조하는 작성자가 모두 존재하는지 확인하는 예
SELECT COUNT(*) AS orphan_article_count
FROM articles AS a
LEFT JOIN users AS u ON u.id = a.author_id
WHERE u.id IS NULL;
-- 공개 게시글인데 발행 시각이 없는 잘못된 상태 확인
SELECT COUNT(*) AS invalid_published_state
FROM articles
WHERE status = 'published'
  AND published_at IS NULL;

5. 애플리케이션 smoke test

- 로그인 대신 격리된 test principal 사용
- 게시글 목록 조회
- 게시글 상세 조회
- 신규 test 게시글 작성
- object upload/read
- transaction rollback
- search index 재생성의 최소 경로

복구 시점 이후에 생성된 데이터가 없어야 한다는 것도 확인한다.

SELECT MAX(created_at) AS newest_row
FROM audit_events;

target time이 14:19인데 14:21 row가 있다면 잘못된 지점까지 replay했을 수 있다. 시간대와 database timestamp 의미를 함께 확인한다.

Point-in-Time Recovery는 로그 연속성이 핵심이다

PITR은 base backup과 그 이후의 변경 로그를 조합한다.

Base Backup
  + WAL/binlog segment 1
  + WAL/binlog segment 2
  + ...
  + target 직전 segment
  = target 시점의 database
flowchart LR
    B[Base backup 02:00] --> L1[Log 02:00-08:00]
    L1 --> L2[Log 08:00-12:00]
    L2 --> L3[Log 12:00-14:19]
    L3 --> R[Recovered 14:19]

중간 segment 하나라도 없으면 이후 log가 있어도 연속적으로 재생하지 못할 수 있다.

base backup: available
WAL 001: available
WAL 002: missing
WAL 003: available

result: target beyond WAL 002 cannot be reached

PostgreSQL에서는 base backup과 archived WAL을 조합해 특정 시점까지 재생할 수 있다. WAL은 database data 변경을 복구하지만 수동으로 관리하는 구성 파일까지 모두 복구하는 것은 아니다. postgresql.conf, 접근 제어, 인증서, extension package 같은 외부 구성은 별도로 보존해야 한다.

복구 target은 애매하지 않게 정한다.

bad transaction: 2026-04-10 14:20:13.842 KST
recovery target: named restore point before maintenance
timezone: Asia/Seoul
source of truth: database audit log

가능하면 위험한 변경 전에 named restore point를 만든다. timestamp만 사용할 때는 애플리케이션 로그, DB 서버, 운영자 화면의 timezone 차이를 확인한다.

원본 위에서 PITR을 시도하지 않는다

먼저 별도 인스턴스로 복구해 target을 검증한다. 잘못 선택한 target으로 현재 database를 덮으면 사고 이후의 유효 데이터까지 잃을 수 있다.

암호화 키와 권한도 복구 대상이다

Backup을 암호화하는 것은 필수적인 보호 수단이지만 key를 잃으면 정상적인 backup도 사용할 수 없다.

Encrypted Backup
   + Decryption Key
   + IAM Permission
   + Key Policy
   + Network Access
   = Readable Restore Input

복구 drill에서 평상시 application role이 아니라 재해 복구용 역할로 직접 복호화해 본다.

확인할 것

- recovery account가 backup 목록을 조회할 수 있는가
- 선택한 object를 읽을 수 있는가
- 해당 시점의 key version으로 복호화 가능한가
- key가 primary account 장애와 독립적인가
- break-glass 승인이 실제 시간 안에 가능한가
- 감사 로그에 누가 어떤 backup을 복구했는지 남는가

key rotation과 backup retention을 함께 본다. backup은 7년 보관하지만 이전 key material을 1년 뒤 폐기하면 나머지 6년 backup은 복구하지 못할 수 있다.

backup retention: 7 years
key retention:    1 year
→ policy mismatch

반대로 key 접근 권한을 너무 넓게 주면 공격자가 backup까지 삭제하거나 복호화할 수 있다. backup writer, backup administrator, restore operator, key administrator 역할을 분리하고 비상 절차를 정기적으로 연습한다.

재구성한 Restore Drill 자동화 예제

다음은 특정 cloud나 database의 실제 운영 script가 아니라 restore drill orchestration을 설명하기 위한 TypeScript 예제다.

type RestorePoint = {
  backupId: string;
  startedAt: string;
  completedAt: string;
  engineVersion: string;
  encryptionKeyId: string;
};

type DrillResult = {
  drillId: string;
  restorePoint: RestorePoint;
  targetTime: string;
  startedAt: string;
  completedAt?: string;
  checks: Array<{
    name: string;
    status: "passed" | "failed";
    elapsedSeconds: number;
    detail?: string;
  }>;
};

상위 흐름은 단계별 결과와 시간을 기록한다.

async function runRestoreDrill(input: {
  drillId: string;
  targetTime: Date;
}): Promise<DrillResult> {
  assertRecoveryEnvironment();

  const result: DrillResult = {
    drillId: input.drillId,
    restorePoint: await selectRestorePoint(input.targetTime),
    targetTime: input.targetTime.toISOString(),
    startedAt: new Date().toISOString(),
    checks: [],
  };

  const environment = await provisionIsolatedEnvironment(input.drillId);

  try {
    await timedCheck(result, "backup-access", () =>
      verifyBackupReadable(result.restorePoint),
    );

    await timedCheck(result, "database-restore", () =>
      restoreDatabase({
        environment,
        restorePoint: result.restorePoint,
        targetTime: input.targetTime,
      }),
    );

    await timedCheck(result, "database-integrity", () =>
      verifyDatabaseIntegrity(environment),
    );

    await timedCheck(result, "application-smoke-test", () =>
      runIsolatedSmokeTests(environment),
    );

    result.completedAt = new Date().toISOString();
    return result;
  } finally {
    await quarantineAndScheduleDeletion(environment);
  }
}

단계가 실패하더라도 시간과 이유를 남긴다.

async function timedCheck(
  result: DrillResult,
  name: string,
  check: () => Promise<void>,
): Promise<void> {
  const startedAt = performance.now();

  try {
    await check();
    result.checks.push({
      name,
      status: "passed",
      elapsedSeconds: (performance.now() - startedAt) / 1_000,
    });
  } catch (error) {
    result.checks.push({
      name,
      status: "failed",
      elapsedSeconds: (performance.now() - startedAt) / 1_000,
      detail: error instanceof Error ? error.message : "unknown error",
    });
    throw error;
  }
}

안전 장치는 코드와 IAM 양쪽에 둔다.

function assertRecoveryEnvironment(): void {
  const accountPurpose = process.env.ACCOUNT_PURPOSE;
  const networkMode = process.env.NETWORK_MODE;

  if (accountPurpose !== "restore-drill") {
    throw new Error("restore drill account is required");
  }

  if (networkMode !== "isolated") {
    throw new Error("isolated network is required");
  }
}

환경 변수 문자열 하나만 믿지 않고 recovery account 자체가 production 변경 권한을 가지지 않게 해야 한다. 애플리케이션 검증은 생성한 test data에만 write하고 외부 side effect를 차단한다.

검증 결과는 machine-readable artifact로 남긴다.

{
  "drill_id": "restore-drill-example",
  "target_time": "2026-04-10T05:19:00.000Z",
  "rpo_gap_seconds": 224,
  "total_elapsed_seconds": 4182,
  "checks": [
    { "name": "backup-access", "status": "passed", "elapsed_seconds": 12 },
    { "name": "database-restore", "status": "passed", "elapsed_seconds": 3520 },
    { "name": "database-integrity", "status": "passed", "elapsed_seconds": 380 },
    { "name": "application-smoke-test", "status": "passed", "elapsed_seconds": 270 }
  ]
}

예시 결과에서는 총 복구 시간이 약 69분이고 목표 지점과 실제 복구 지점 차이가 224초다. 이를 RTO·RPO 목표와 자동 비교할 수 있다.

복구 시간을 단계별로 측정한다

총 소요 시간만 기록하면 어디를 개선해야 할지 알기 어렵다.

T0 incident declared
T1 restore decision approved
T2 access granted
T3 environment ready
T4 backup downloaded
T5 database replay complete
T6 integrity checks complete
T7 application smoke test complete
T8 traffic switched
decision time      = T1 - T0
access time        = T2 - T1
provisioning time  = T3 - T2
data restore time  = T5 - T3
validation time    = T7 - T5
cutover time       = T8 - T7
total recovery time = T8 - T0

복구 데이터가 커지면서 restore 시간도 증가한다. 지난 drill이 RTO 안에 끝났다고 다음 분기도 보장되지 않는다.

database size: 400 GB → 800 GB
restore throughput: 100 MB/s
theoretical transfer time roughly doubles

병목을 측정한 뒤 다음 개선을 선택한다.

병목 개선 후보
승인과 권한 사전 승인된 break-glass 절차
인프라 생성 검증된 IaC, warm standby
backup 전송 같은 region copy, 병렬 download
log replay base backup 주기 조정
schema 검증 자동화 query
app smoke test 격리된 synthetic suite
DNS 전환 낮은 TTL과 사전 검증

목표는 무조건 복구 시간을 줄이는 것이 아니라 비즈니스가 정한 RTO를 예측 가능하게 만족하는 것이다.

복구 테스트가 운영을 망치지 않게 한다

Restore drill 자체가 위험한 변경이 될 수 있다.

Runbook에 안전 조건과 중단 기준을 둔다.

사전 조건
- recovery account와 production account 분리
- production write IAM 명시적 deny
- outbound email/SMS/webhook 차단
- 별도 queue namespace
- 비용 한도와 자동 만료 tag
- network throughput 제한
- 개인정보 접근 승인
중단 조건
- production latency 또는 backup job 지연 증가
- 예상하지 않은 외부 egress 탐지
- recovery environment가 production credential을 사용
- 복구 대상과 다른 backup 선택
- 비용 또는 resource quota 한도 접근

복구 시험을 실제 incident처럼 역할 분담해 실행하면 사람과 절차도 검증할 수 있다. 다만 첫 시험부터 전체 region failover를 시도하지 않고 범위를 단계적으로 넓힌다.

Level 1: artifact와 checksum 검증
Level 2: database isolated restore
Level 3: application smoke test
Level 4: dependency 포함 전체 stack
Level 5: traffic 없는 failover rehearsal
Level 6: 제한된 실제 traffic exercise

복구 결과를 기록하고 개선한다

각 drill은 성공·실패 boolean보다 자세한 결과를 남긴다.

Drill ID
Scenario
Selected restore point
Actual recovered point
RPO gap
Target RTO
Actual RTO
Data size
Environment and versions
Checks and failures
Manual steps
Permissions requested
Unexpected side effects
Follow-up owners and due dates

다음 지표를 추세로 관리할 수 있다.

Drill이 실패하면 “테스트 환경 문제”로 닫지 않는다. 재해 때도 같은 도구와 권한이 필요하다면 실제 복구 결함이다.

Finding:
- recovery role cannot decrypt backups older than 90 days

Impact:
- 1-year retention policy is not usable

Action:
- align key retention with backup retention
- add quarterly old-restore-point test

Owner:
- Platform Security

Due:
- 2026-05-01

후속 작업이 완료되면 같은 시나리오를 다시 실행해 증명한다. 문서만 수정해서는 복구 가능성이 올라가지 않는다.

장애 중 복구 절차는 장애 대응 Runbook에 들어가야 할 내용의 형식으로 관리한다. Drill 결과를 Runbook 마지막 검증일과 연결하면 문서가 실제로 실행됐는지 알 수 있다.

마무리

Backup은 복구를 위한 입력 중 하나다. 파일이 존재하고 checksum이 맞는 것은 중요하지만 사용 가능한 서비스를 되돌리는 전체 능력을 증명하지는 않는다.

복구 가능성은 격리된 환경에서 실제 backup을 선택하고, 복호화하고, 재생하고, 데이터와 애플리케이션을 검증해 목표 시간 안에 완료했을 때만 확인된다.

실무에서 확인할 기준은 다음과 같다.

  1. backup 성공률과 restore drill 성공률을 분리한다.
  2. replica, snapshot, logical dump, log archive의 실패 범위를 이해한다.
  3. DB뿐 아니라 object storage, config, key, secret, DNS와 artifact를 인벤토리화한다.
  4. 장애 시나리오별 RPO와 RTO를 정한다.
  5. production write가 불가능한 격리 환경에서 복구한다.
  6. checksum, engine, schema, 도메인 정합성, smoke test를 단계별로 수행한다.
  7. PITR의 base backup과 모든 log segment 연속성을 확인한다.
  8. 오래된 key와 recovery role의 실제 권한을 검증한다.
  9. 의사결정부터 traffic 전환까지 시간을 단계별로 측정한다.
  10. drill 결과의 결함을 owner와 기한이 있는 후속 작업으로 관리한다.

백업은 매일 자동으로 만들 수 있지만 복구 능력은 연습하지 않으면 사라진다. 실제 사고 전에 실패를 발견할 수 있다는 점이 restore drill의 가장 큰 가치다.

참고 자료

관련 노트